WebView 로그인 세션을 안전하게 전달하기

WebView 로그인 세션을 안전하게 전달하기

한눈에 보기

Native 앱의 refresh token을 URL, JavaScript 전역 변수, localStorage에 복사하지 않는다. Native는 플랫폼 보안 저장소에서 장기 credential을 소유하고, 서버에서 WebView 전용·짧은 수명·좁은 권한의 session을 발급받는다. 가능하면 Secure, HttpOnly, 적절한 SameSite 속성의 cookie를 WebView cookie store에 설정해 JavaScript가 token 값을 읽지 못하게 한다. Bearer token을 bridge로 전달해야 한다면 access token만 memory에 두고 origin·top frame·만료·logout 경쟁을 함께 통제한다.

목차

Native 로그인과 Web 로그인을 연결할 때 생기는 위험

Native에서 이미 로그인했는데 WebView가 다시 로그인 화면을 보여 주면 사용자 경험이 나쁘다. 그래서 가장 빨리 떠오르는 방법은 URL query에 token을 붙이는 것이다.

// 피해야 할 예
final url = Uri.https(
  'app.example.invalid',
  '/web/orders',
  {'access_token': accessToken},
);

await webViewController.loadRequest(url);

URL은 생각보다 많은 곳에 복제된다.

전역 JavaScript 변수에 주입하는 방식도 안전하지 않다.

await controller.runJavaScript(
  'window.__SESSION__ = "$accessToken";',
);

문자열 escaping 문제뿐 아니라 같은 page context의 script가 값을 읽을 수 있다. XSS가 있다면 token을 곧바로 가져간다. page reload 뒤 재주입 시점 경쟁도 생긴다.

localStorage에 넣으면 reload 문제는 줄어 보이지만 장기 노출 시간이 늘어난다.

// 피해야 할 예
localStorage.setItem("refresh_token", token);

WebView의 DOM storage와 cookie, cache는 각각 별도 subsystem이다. clearCache() 하나로 로그인 흔적이 모두 지워진다고 가정할 수 없다.

핵심 목표

Native의 로그인 상태를 그대로 복제하는 것이 아니라, WebView가 필요한 동안만 사용할 별도 session을 안전하게 위임한다.

이 글의 도메인과 token은 모두 가상의 예시이며 실제 서비스 credential이나 코드가 아니다.

먼저 보호해야 할 자산과 공격 경로를 적기

세션 전달 방식을 고르기 전에 threat model을 간단히 적는다.

자산 탈취됐을 때 영향 노출 경로
Native refresh token 장기간 access token 재발급 bridge, Web storage, 로그
Web access token 만료 전 API 호출 XSS, JavaScript memory
Web session cookie Web session 탈취 cookie store, network
사용자 식별 정보 privacy 침해 URL, telemetry, page source
logout 상태 이전 session 재사용 양쪽 저장소 불일치

공격 가능성도 구체화한다.

이 목록을 보면 refresh token을 Web에 넘기지 않는 것이 첫 번째 경계가 된다. access token도 가능하면 JavaScript가 읽을 수 없는 cookie 기반 session으로 바꾼다.

장기 Credential과 Web Session을 분리하기

인증 값을 수명과 권한으로 나눈다.

flowchart LR
    A[Native refresh credential] -->|보안 저장소| B[Native auth repository]
    B -->|인증된 요청| C[Session handoff API]
    C --> D[짧은 Web session]
    D --> E[WebView cookie store]
    E --> F[Web API]
소유자 수명 권한 저장 위치
Refresh credential Native auth repository 상대적으로 김 token 재발급 Keychain·secure storage
Native access token Native API client 짧음 Native API scope memory 중심
Web session WebView HTTP layer 짧음 Web 기능 scope HttpOnly cookie 우선
CSRF token Web app session 연계 요청 위조 방지 정책에 맞는 Web state

Web session은 Native session과 다른 식별자와 만료를 가져야 한다. 하나가 폐기돼도 다른 session을 추적해 revoke할 수 있도록 서버가 부모-자식 관계를 기록할 수 있다.

native_session_id: ns_demo_4
web_session_id: ws_demo_9
audience: embedded-web
scope: orders:read orders:write
expires_in: 10 minutes
parent: ns_demo_4

WebView가 필요한 권한만 scope로 제한한다. Native refresh token과 같은 bearer value를 두 환경에서 공유하면 어느 쪽 침해도 전체 session 침해가 된다.

세션 전달 방식 비교

방식 JavaScript 노출 장점 주요 위험
URL query token 높음 구현이 단순해 보임 URL·로그·history 유출
JS 전역 변수 주입 높음 page에서 즉시 사용 XSS·escaping·재주입 경쟁
localStorage bearer 높음·지속 reload 후 유지 XSS와 logout 잔존
Bridge로 단기 access token 높음·memory API client 재사용 XSS 시 탈취, refresh 경계 필요
Native가 Web session cookie 설정 HttpOnly면 낮음 브라우저 HTTP 흐름 활용 cookie 완료·CSRF·logout 관리
Web 내부 OAuth login Web 정책에 따름 표준 browser flow 사용자 재로그인 UX

이 글의 기본 선택은 WebView 전용 session cookie다. 다만 cookie라고 자동으로 안전한 것은 아니다.

cookie 속성은 앱이 임의로 고르기보다 Web backend의 session 정책과 함께 설계한다.

권장 흐름은 서버가 Web 전용 Session을 발급하는 것

Native가 refresh credential로 Web session handoff API를 호출한다.

POST /v1/embedded-web/sessions HTTP/1.1
Authorization: Bearer native-access-token
Content-Type: application/json

{
  "audience": "embedded-web",
  "requestedPath": "/orders",
  "deviceSessionId": "device-session-demo"
}

서버는 WebView 전용 cookie material과 만료, 허용 시작 URL을 반환한다. 실제 API에서는 cookie value가 telemetry에 남지 않도록 response body와 logging 정책을 별도로 확인한다.

{
  "session": {
    "name": "__Host-embedded_session",
    "value": "opaque-demo-value",
    "expiresAt": "2026-01-06T03:10:00Z",
    "secure": true,
    "httpOnly": true,
    "sameSite": "Lax"
  },
  "startUrl": "https://app.example.invalid/orders"
}

전체 흐름은 다음과 같다.

sequenceDiagram
    participant UI as Native UI
    participant Auth as Native Auth
    participant API as Session API
    participant Store as WebView Cookie Store
    participant Web as Web App

    UI->>Auth: Hybrid 화면 열기
    Auth->>API: Web session 발급
    API-->>Auth: 짧은 cookie + start URL
    Auth->>Auth: host·expiry·attribute 검증
    Auth->>Store: cookie 설정
    Store-->>Auth: 설정 완료
    Auth->>Web: 허용된 start URL load
    Web->>API: cookie가 포함된 요청

중요한 순서는 cookie 설정 완료 뒤 navigation이다. 비동기 cookie 저장이 끝나기 전에 URL을 로드하면 첫 요청만 anonymous가 되어 login redirect가 깜빡이거나 잘못 cache될 수 있다.

WKHTTPCookieStore는 특정 WebView의 HTTP cookie를 관리한다. WebView configuration과 같은 data store의 cookie store를 사용해야 한다.

struct EmbeddedWebSession {
    let value: String
    let expiresAt: Date
    let startURL: URL
}

enum WebSessionBootstrapError: Error {
    case invalidStartURL
    case invalidCookie
}

허용 host를 확인하고 HTTPCookie를 만든다.

func makeSessionCookie(
    session: EmbeddedWebSession
) throws -> HTTPCookie {
    guard
        session.startURL.scheme == "https",
        session.startURL.host == "app.example.invalid"
    else {
        throw WebSessionBootstrapError.invalidStartURL
    }

    let properties: [HTTPCookiePropertyKey: Any] = [
        .name: "__Host-embedded_session",
        .value: session.value,
        .domain: "app.example.invalid",
        .path: "/",
        .secure: "TRUE",
        .expires: session.expiresAt
    ]

    guard let cookie = HTTPCookie(properties: properties) else {
        throw WebSessionBootstrapError.invalidCookie
    }

    return cookie
}

HttpOnly와 SameSite 속성의 생성·지원 방식은 대상 OS와 사용하는 API에서 실제로 검증한다. cookie 이름 규칙을 사용한다면 domain attribute 등 해당 규칙의 요구사항과 생성 결과가 일치하는지 확인해야 한다.

cookie 설정 완료 뒤 load한다.

@MainActor
func bootstrap(
    webView: WKWebView,
    session: EmbeddedWebSession
) async throws {
    let cookie = try makeSessionCookie(session: session)
    let store = webView.configuration
        .websiteDataStore
        .httpCookieStore

    await withCheckedContinuation { continuation in
        store.setCookie(cookie) {
            continuation.resume()
        }
    }

    webView.load(URLRequest(url: session.startURL))
}

별도의 WKWebsiteDataStore를 configuration에 넣었다면 cookie도 그 store에 넣는다. 다른 store를 수정하고 WebView가 다른 store를 사용하면 설정 성공처럼 보여도 요청에는 cookie가 없다.

Android에서는 CookieManager가 WebView cookie를 관리한다. setCookie callback 결과를 확인하고 navigation한다.

data class EmbeddedWebSession(
    val cookieValue: String,
    val maxAgeSeconds: Long,
    val startUrl: String,
)

fun bootstrapWebView(
    webView: WebView,
    session: EmbeddedWebSession,
    onFailure: (WebSessionError) -> Unit,
) {
    val uri = Uri.parse(session.startUrl)
    if (uri.scheme != "https" ||
        uri.host != "app.example.invalid"
    ) {
        onFailure(WebSessionError.InvalidStartUrl)
        return
    }

    val cookie = buildString {
        append("__Host-embedded_session=")
        append(session.cookieValue)
        append("; Path=/")
        append("; Max-Age=${session.maxAgeSeconds}")
        append("; Secure")
        append("; HttpOnly")
        append("; SameSite=Lax")
    }

    CookieManager.getInstance().setCookie(
        "https://app.example.invalid",
        cookie,
    ) { success ->
        if (success) {
            webView.loadUrl(session.startUrl)
        } else {
            onFailure(WebSessionError.CookieRejected)
        }
    }
}

cookie value에 header 구분 문자가 들어가지 않는 opaque server-generated format을 사용하고, 원문을 로그로 출력하지 않는다. 앱이 필요한 third-party cookie 정책도 별도로 검토한다. 인증을 위해 무조건 third-party cookie 전체를 허용하는 식으로 해결하지 않는다.

Bridge로 Access Token을 전달해야 하는 경우

Web application이 bearer token API client로 이미 구성돼 있고 cookie session endpoint를 만들기 어려울 수 있다. 이 경우에도 refresh token은 Native에 남긴다. Web은 신뢰한 top frame에서 짧은 access token을 요청하고 memory에만 보관한다.

type AccessSession = {
  accessToken: string;
  expiresAtEpochMs: number;
  audience: "embedded-web";
};

class InMemorySession {
  private current: AccessSession | null = null;

  set(session: AccessSession): void {
    this.current = session;
  }

  clear(): void {
    this.current = null;
  }

  getUsable(now: number): AccessSession | null {
    if (!this.current) return null;
    if (this.current.expiresAtEpochMs - now < 30_000) return null;
    return this.current;
  }
}

session 요청은 WebView 브릿지를 버전 있는 프로토콜로 만들기의 envelope을 사용한다.

const response = await bridge.request<AccessSession>(
  "AUTH_SESSION_REQUEST",
  {
    audience: "embedded-web",
    minValiditySeconds: 30,
  },
);

sessionStore.set(validateAccessSession(response));

Native는 다음을 검증한다.

Web은 token을 localStorage, IndexedDB, cookie via JavaScript, Redux persistence에 저장하지 않는다. page reload 시 다시 요청한다. 이 방식은 XSS가 실행 중인 동안 access token을 훔칠 위험을 없애지는 못한다. 그래서 수명을 짧게 하고 server-side authorization과 CSP를 함께 적용한다.

HttpOnly와 Bridge의 차이

HttpOnly cookie는 JavaScript가 값을 직접 읽지 못하게 한다. Bridge로 반환한 bearer token은 그 순간 JavaScript memory에 존재한다. 두 방식의 공격 표면은 같지 않다.

Token Refresh의 단일 소유자를 유지하기

Native와 Web이 같은 refresh token으로 각각 갱신하면 rotation 경쟁이 발생한다.

sequenceDiagram
    participant Web
    participant Native
    participant Server

    Web->>Server: refresh token R1 사용
    Native->>Server: refresh token R1 사용
    Server-->>Web: R2 발급, R1 폐기
    Server-->>Native: reuse 감지 또는 실패
    Native-->>Web: session invalid event

refresh는 Native auth repository 하나만 수행한다. Web이 새 access session을 요청하면 Native가 현재 token을 확인하고 필요하면 single-flight refresh를 실행한다.

Future<AccessSession> getWebAccessSession() {
  final usable = cache.currentWebSession;
  if (usable != null && !usable.expiresSoon(clock.now())) {
    return Future.value(usable);
  }

  return _refreshInFlight ??= _refreshWebSession()
      .whenComplete(() => _refreshInFlight = null);
}

동시에 요청한 여러 Web API가 모두 refresh bridge를 호출해도 Native network 요청은 하나만 실행된다. 성공 결과는 현재 Native auth generation과 연결한다.

401을 받았을 때 무한 재시도하지 않기

Web API client는 401 하나마다 refresh와 재시도를 무한 반복하지 않는다.

async function authorizedFetch(
  input: RequestInfo,
  init: RequestInit = {},
): Promise<Response> {
  const firstSession = await sessionProvider.get();
  const first = await fetchWithSession(input, init, firstSession);

  if (first.status !== 401) {
    return first;
  }

  sessionProvider.invalidate(firstSession);
  const refreshed = await sessionProvider.get();

  return fetchWithSession(input, init, refreshed);
}

재시도는 한 번으로 제한하고, 쓰기 요청은 idempotency를 검토한다. 첫 요청이 서버에서 처리됐지만 응답만 401이나 network error로 보인 특수 상황이라면 중복 실행이 위험하다.

새 session 요청도 실패하면 Web은 authenticated route를 닫고 Native에 상태 확인을 요청한다. 자체적으로 login 화면을 띄울지 Native login 화면으로 나갈지는 하나의 navigation 정책으로 정한다.

Logout은 양쪽 저장소를 함께 폐기하기

logout은 Native Keychain만 지우거나 Web cookie만 지우는 것으로 끝나지 않는다.

sequenceDiagram
    participant User
    participant Native
    participant Server
    participant Cookie as Web Cookie Store
    participant Web

    User->>Native: logout
    Native->>Native: auth generation 증가
    Native->>Server: native/web session revoke
    Native->>Cookie: session cookie 삭제
    Native->>Web: SESSION_REVOKED event
    Web->>Web: memory session·query cache 제거
    Native->>Native: Keychain credential 삭제

실제로는 network revoke가 실패할 수 있으므로 local logout을 막지 않되 retry 정책을 둔다. 먼저 auth generation을 증가시키면 logout 전에 시작한 refresh 결과가 늦게 도착해도 새 credential로 저장하지 못하게 할 수 있다.

Future<void> logout() async {
  final logoutGeneration = authState.beginLogout();

  unawaited(
    sessionApi.revokeAll().catchError(logRevokeFailure),
  );

  await Future.wait([
    credentialStore.clear(),
    webSessionStore.clearCookies(),
    webViewState.clearSiteData(),
  ]);

  authState.finishLogout(logoutGeneration);
}

어떤 Web data를 지울지 명시한다. cookie 삭제, memory state, Web storage, HTTP cache는 서로 다른 저장 영역이다. 앱 안에 여러 Web 계정을 지원한다면 모든 site data 삭제가 다른 계정이나 비인증 설정까지 없애는지도 검토한다.

Origin과 Frame이 바뀌면 Session Capability를 닫기

허용한 Web origin에서 외부 결제·도움말 페이지로 이동할 수 있다. WebView instance가 같다는 이유로 bridge의 session capability를 계속 노출하지 않는다.

https://app.example.invalid/orders     허용
https://help.example.invalid/article   인증 bridge 비활성
https://external.invalid/              외부 browser

Native는 navigation commit 시 현재 top-level origin과 bridge session을 갱신한다. iframe에서 들어온 AUTH_SESSION_REQUEST는 거절한다. allowlist는 문자열 포함 검사가 아니라 URL parser의 scheme, host, port를 사용한다.

Web server도 CORS와 server-side authorization을 적용한다. client가 보낸 role, account ID, isNativeApp flag를 권한 근거로 믿지 않는다.

Page Reload와 앱 Lifecycle 처리하기

page reload가 발생하면 JavaScript memory session은 사라진다. bridge handshake를 다시 하고 새 session ID로 access session을 요청한다. 이전 page의 늦은 response는 폐기한다.

앱이 background로 갔다가 돌아오면 무조건 새 token을 주입하지 않는다.

bridgeEvents.on("APP_LIFECYCLE_CHANGED", async (event) => {
  if (event.payload.state !== "resumed") return;

  if (sessionStore.getUsable(Date.now())) return;

  try {
    sessionStore.set(await sessionProvider.requestFresh());
  } catch {
    router.replace("/session-required");
  }
});

background에 있는 동안 Web session이 revoke됐을 수 있으므로 resume은 재검사 trigger일 뿐 “여전히 로그인”이라는 증거가 아니다. 앱 process가 종료돼도 cookie가 남을지, 매번 bootstrap할지는 WebView data store와 제품 정책으로 정한다.

테스트해야 할 인증 상태 행렬

정상 login 한 번으로는 세션 경계를 검증할 수 없다.

상황 기대 결과
cookie 설정 성공 뒤 첫 navigation 첫 요청부터 authenticated
cookie 설정 실패 WebView load 중단, 명시적 오류
access session 만료 30초 전 Native single-flight 갱신
동시 401 다섯 개 refresh 한 번, 각 요청 최대 한 번 재시도
Web page reload 새 session ID, 이전 response 폐기
외부 origin 이동 session bridge 비활성
iframe의 session 요청 거절
logout 중 refresh 완료 generation 불일치로 저장 폐기
server revoke 실패 local logout 완료, revoke 재시도
기기 잠금 중 Keychain 불가 stale UI 또는 Native login 안내
앱 계정 전환 이전 cookie·Web cache 제거

보안 테스트에는 XSS를 가정한 script가 refresh token에 접근할 수 없는지, cookie가 JavaScript에서 보이지 않는지, bridge access token이 persistent storage에 남지 않는지도 포함한다.

운영 로그에 Token을 남기지 않기

관측에는 token 값이 필요하지 않다.

web_session_issue result=success ttl_seconds=600
web_cookie_set platform=ios result=success
web_session_refresh result=success shared_waiters=4
web_session_request result=denied reason=origin_mismatch
logout_cleanup cookie=success keychain=success revoke=pending

기록하지 않을 값:

session을 연결해야 한다면 server가 발급한 비가역·비식별 correlation ID를 별도로 사용한다. token 일부를 잘라 ID로 쓰는 방식도 피한다.

구현 체크리스트

Session 경계

전달 방식

Refresh와 Logout

검증

마무리

WebView 로그인 연결은 Native token을 Web에 복사하는 작업이 아니다. 서로 다른 실행 환경에 어느 정도의 권한을 얼마나 오래 위임할지 정하는 session 설계다.

Native refresh credential은 Keychain 같은 플랫폼 보안 저장소에 남긴다. 서버는 이를 근거로 WebView 전용의 짧고 제한된 session을 발급한다. 가능하면 WebView HTTP cookie store에 HttpOnly session을 설정해 JavaScript가 값에 직접 접근하지 못하게 하고, 설정 완료 뒤 허용된 HTTPS URL을 연다.

기존 Web API 구조 때문에 bearer token을 bridge로 전달한다면 refresh token은 제외하고 access token만 memory에서 짧게 사용한다. origin·top frame·page session을 검증하고 refresh는 Native 한곳에서만 수행한다. logout은 Keychain, cookie, Web storage, in-flight refresh를 하나의 상태 전환으로 처리한다.

안전한 handoff의 목표는 token을 더 복잡하게 숨기는 것이 아니다. 침해 가능한 한 경계가 가진 권한과 수명을 줄이고, 폐기 시점에 모든 복제본이 함께 사라지게 만드는 것이다.

관련 노트

참고 자료